Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /admin/courriel/[id] — lecture d'un courriel, version rehaussée : en-tête3// avec avatar du correspondant, métadonnées propres, corps typographié,4// pièces jointes, actions et réponse inline. Le HTML entrant n'est JAMAIS5// rendu brut — texte extrait seulement. L'ouverture marque le message lu.6// Accès : propriétaire de la boîte, ou super admin pour les non-routés.7import { notFound, redirect } from "next/navigation";8import { getAdminSession } from "@/lib/comms/admin-auth";9import { findAdminById, findSubmissionById } from "@/lib/comms/db";10import {11 findMail,12 canAccessMail,13 updateMail,14 mailAttachmentsOf,15 MAIL_FOLDERS,16} from "@/lib/comms/mail";17import { hydrateInboundMail } from "@/lib/comms/resend-fetch";18import { MailComposer, MailActions } from "../MailClient";19import { timeAgo, fmtDateFull, initialsOf, avatarStyle } from "../../format";2021export const dynamic = "force-dynamic";2223function fmtBytes(n: number): string {24 if (n < 1024) return `${n} o`;25 if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;26 return `${(n / 1024 / 1024).toFixed(1)} Mo`;27}2829/** HTML entrant : on n'affiche JAMAIS le HTML brut — texte extrait seulement. */30function htmlToText(html: string): string {31 return html32 .replace(/<style[\s\S]*?<\/style>/gi, " ")33 .replace(/<script[\s\S]*?<\/script>/gi, " ")34 .replace(/<br\s*\/?>/gi, "\n")35 .replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n")36 .replace(/<[^>]+>/g, "")37 .replace(/ /g, " ")38 .replace(/&/g, "&")39 .replace(/</g, "<")40 .replace(/>/g, ">")41 .replace(/\n{3,}/g, "\n\n")42 .trim();43}4445export default async function MailViewPage({46 params,47}: {48 params: Promise<{ id: string }>;49}) {50 const session = await getAdminSession();51 if (!session) redirect("/admin/connexion");52 const id = Number((await params).id);53 const mail = Number.isInteger(id) ? findMail(id) : undefined;54 const superAdmin = session.roles.includes("super_admin");55 if (!mail || !canAccessMail(mail, session.id, superAdmin)) notFound();5657 if (!mail.is_read) updateMail(mail.id, { is_read: true });5859 // Rattrapage : un entrant sans corps (webhook = métadonnées seulement)60 // est hydraté via l'API Resend à l'ouverture — si une clé lisante existe.61 let bodyText = mail.body_text;62 let bodyHtml = mail.body_html;63 let hydrationFailed = false;64 if (mail.direction === "in" && !bodyText && !bodyHtml) {65 const hydrated = await hydrateInboundMail(mail).catch(() => null);66 if (hydrated) {67 bodyText = hydrated.text;68 bodyHtml = hydrated.html;69 } else hydrationFailed = true;70 }7172 const atts = mailAttachmentsOf(mail.id);73 const admin = findAdminById(session.id);74 const to = JSON.parse(mail.to_emails) as string[];75 const cc = mail.cc_emails ? (JSON.parse(mail.cc_emails) as string[]) : [];76 const body =77 bodyText?.trim() || (bodyHtml ? htmlToText(bodyHtml) : "");78 const linked = mail.submission_id ? findSubmissionById(mail.submission_id) : undefined;79 const isOut = mail.direction === "out";80 const counterpartEmail = isOut ? (to[0] ?? "?") : mail.from_email;8182 return (83 <div className="mx-auto max-w-4xl">84 <nav aria-label="Fil d'Ariane" className="flex items-center justify-between gap-3">85 <a86 href={`/admin/courriel?folder=${mail.folder}`}87 className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green"88 >89 ← {MAIL_FOLDERS[mail.folder] ?? "Courriel"}90 </a>91 <span className="gk-mono text-[10px] text-ink-3" title={fmtDateFull(mail.created_at)}>92 {timeAgo(mail.created_at)}93 </span>94 </nav>9596 <div className="gk-card mt-4 overflow-hidden">97 {/* en-tête correspondant */}98 <div className="flex flex-wrap items-center gap-4 border-b-[1.5px] border-ink bg-surface-2 px-5 py-4 sm:px-7">99 <span className="adm-avatar !h-[48px] !w-[48px] !text-[17px]" style={avatarStyle(counterpartEmail)}>100 {initialsOf(isOut ? null : mail.from_name, counterpartEmail)}101 </span>102 <div className="min-w-0 flex-1">103 <p className="truncate text-[15px] font-bold">104 {isOut105 ? `Vous → ${to.join(", ")}`106 : mail.from_name107 ? `${mail.from_name}`108 : mail.from_email}109 </p>110 <p className="gk-mono mt-[1px] truncate text-[10.5px] text-ink-3">111 {isOut ? `de ${mail.from_email}` : mail.from_email}112 {cc.length ? ` · cc ${cc.join(", ")}` : ""}113 {!isOut ? ` · à ${to.join(", ")}` : ""}114 </p>115 </div>116 <div className="flex flex-none flex-wrap gap-2">117 <span className="adm-chip adm-chip--ghost !text-[9px]">118 {isOut ? "Envoyé" : "Reçu"}119 </span>120 {mail.admin_id === null ? (121 <span className="adm-chip adm-chip--haute !text-[9px]">Non routé</span>122 ) : null}123 </div>124 </div>125126 {/* sujet + demande liée */}127 <div className="px-5 pt-5 sm:px-7">128 <h1 className="gk-display text-[clamp(19px,2.6vw,26px)] leading-tight font-bold tracking-[-0.03em]">129 {mail.subject || "(sans sujet)"}130 </h1>131 {linked ? (132 <p className="mt-3 inline-flex flex-wrap items-center gap-2 rounded-lg border-[1.5px] border-green bg-lime-soft px-3 py-2 text-[12px]">133 <span className="gk-mono font-bold tracking-[0.06em] text-green uppercase">Demande liée</span>134 <a href={`/admin/demande/${linked.reference}`} className="gk-mono font-bold text-ink underline underline-offset-4">135 {linked.reference}136 </a>137 <span className="text-ink-2">— aussi versé dans sa conversation</span>138 </p>139 ) : null}140 </div>141142 {/* corps */}143 <div className="px-5 py-5 sm:px-7 sm:py-6">144 {body ? (145 <p className="max-w-[68ch] text-[15px] leading-[1.75] whitespace-pre-wrap">{body}</p>146 ) : hydrationFailed ? (147 <div className="rounded-lg border-[1.5px] border-amber bg-amber-soft px-4 py-3">148 <p className="gk-mono text-[10.5px] font-bold tracking-[0.08em] text-[#7a4d0d] uppercase">149 Contenu non transmis par le webhook Resend150 </p>151 <p className="mt-2 text-[13px] leading-relaxed text-ink-2">152 Resend ne livre que les métadonnées dans l'événement — le153 corps se lit via son API, qui exige une clé avec accès154 lecture. Créez une clé « Full access » dans le155 dashboard Resend, collez-la dans{" "}156 <code className="gk-mono text-[11.5px] font-bold">RESEND_READ_API_KEY</code>{" "}157 (.env.local) puis redémarrez : ce message sera récupéré158 automatiquement à sa prochaine ouverture.159 </p>160 </div>161 ) : (162 <p className="gk-mono text-[12px] text-ink-3">(message vide)</p>163 )}164 </div>165166 {/* pièces jointes */}167 {atts.length ? (168 <div className="border-t border-line px-5 py-4 sm:px-7">169 <p className="klabel">Pièces jointes ({atts.length})</p>170 <ul className="mt-2 flex flex-wrap gap-2">171 {atts.map((a) => (172 <li key={a.id}>173 <a174 href={`/api/admin/mail/attachments/${a.id}`}175 className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft"176 >177 ⭳ {a.original_name}178 <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span>179 </a>180 </li>181 ))}182 </ul>183 </div>184 ) : null}185186 {/* barre d'actions */}187 <div className="border-t-[1.5px] border-ink bg-surface-2 px-5 py-3 sm:px-7">188 <MailActions189 id={mail.id}190 folder={mail.folder}191 isRead={true}192 unrouted={mail.admin_id === null}193 />194 </div>195 </div>196197 {/* réponse */}198 {mail.direction === "in" ? (199 <div className="mt-5">200 <MailComposer201 fromEmail={admin?.email ?? ""}202 replyTo={{203 id: mail.id,204 to: mail.from_email,205 subject: mail.subject ?? "",206 }}207 startOpen={false}208 />209 </div>210 ) : null}211 </div>212 );213}214